|
|
@@ -62,6 +62,11 @@ import WatchConnectivity
|
|
|
|
|
|
var recommendedBolus: Decimal = 0
|
|
|
|
|
|
+ /// Snapshots older than this are dropped at the top of the WC delegate
|
|
|
+ /// methods. Single source of truth for both `didReceiveMessage` and
|
|
|
+ /// `didReceiveUserInfo`.
|
|
|
+ private static let maxAcceptableMessageAge: TimeInterval = 15 * 60
|
|
|
+
|
|
|
// MARK: - Debouncing and batch processing helpers
|
|
|
|
|
|
/// Temporary storage for new data arriving via WatchConnectivity.
|
|
|
@@ -180,92 +185,73 @@ import WatchConnectivity
|
|
|
await WatchLogger.shared.log("⌚️ Watch received data: \(message)")
|
|
|
}
|
|
|
|
|
|
- // If the message has a nested "watchState" dictionary with date as TimeInterval
|
|
|
- if let watchStateDict = message[WatchMessageKeys.watchState] as? [String: Any],
|
|
|
- let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
|
|
|
- {
|
|
|
- let date = Date(timeIntervalSince1970: timestamp)
|
|
|
-
|
|
|
- // Check if it's not older than 15 min
|
|
|
- if date >= Date().addingTimeInterval(-15 * 60) {
|
|
|
- Task {
|
|
|
- await WatchLogger.shared.log("⌚️ Handling watchState from \(date)")
|
|
|
- }
|
|
|
- processWatchMessage(message)
|
|
|
- } else {
|
|
|
- Task {
|
|
|
- await WatchLogger.shared.log("⌚️ Received outdated watchState data (\(date))")
|
|
|
- }
|
|
|
- DispatchQueue.main.async {
|
|
|
- self.showSyncingAnimation = false
|
|
|
- }
|
|
|
- }
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // Else if the message is an "ack" at the top level
|
|
|
- // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
|
|
|
- else if
|
|
|
- let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
|
|
|
- let ackMessage = message[WatchMessageKeys.message] as? String,
|
|
|
- let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
|
|
|
+ // Ack at top level — no `watchState` wrapper, no staleness check.
|
|
|
+ if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
|
|
|
+ let ackMessage = message[WatchMessageKeys.message] as? String,
|
|
|
+ let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
|
|
|
{
|
|
|
Task {
|
|
|
await WatchLogger.shared
|
|
|
.log("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged), ackCode: \(ackCodeRaw)")
|
|
|
}
|
|
|
DispatchQueue.main.async {
|
|
|
- // For ack messages, we do NOT show “Syncing...”
|
|
|
self.showSyncingAnimation = false
|
|
|
}
|
|
|
processWatchMessage(message)
|
|
|
return
|
|
|
+ }
|
|
|
|
|
|
- // Recommended bolus is also not part of the WatchState message, hence the extra condition here
|
|
|
- } else if
|
|
|
- let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
|
|
|
- {
|
|
|
+ // Recommended bolus is also not part of the WatchState message.
|
|
|
+ if let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber {
|
|
|
Task {
|
|
|
await WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)")
|
|
|
}
|
|
|
-
|
|
|
DispatchQueue.main.async {
|
|
|
self.recommendedBolus = recommendedBolus.decimalValue
|
|
|
self.showBolusCalculationProgress = false
|
|
|
}
|
|
|
-
|
|
|
return
|
|
|
- } else {
|
|
|
- Task {
|
|
|
- await WatchLogger.shared.log("⌚️ Faulty data. Skipping...")
|
|
|
- }
|
|
|
- DispatchQueue.main.async {
|
|
|
- self.showSyncingAnimation = false
|
|
|
- }
|
|
|
}
|
|
|
+
|
|
|
+ handleIncomingWatchStatePayload(message)
|
|
|
}
|
|
|
|
|
|
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
|
|
- guard let snapshot = WatchStateSnapshot(from: userInfo) else {
|
|
|
- Task {
|
|
|
- await WatchLogger.shared.log("⌚️ Invalid snapshot received", force: true)
|
|
|
- }
|
|
|
+ handleIncomingWatchStatePayload(userInfo)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Shared path for watch-state payloads from either delegate method.
|
|
|
+ /// Enforces the freshness contract in one place so the two delivery paths
|
|
|
+ /// can't drift.
|
|
|
+ private func handleIncomingWatchStatePayload(_ dictionary: [String: Any]) {
|
|
|
+ guard let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any],
|
|
|
+ let timestamp = payload[WatchMessageKeys.date] as? TimeInterval
|
|
|
+ else {
|
|
|
+ Task { await WatchLogger.shared.log("⌚️ Faulty watch state payload — skipping", force: true) }
|
|
|
+ DispatchQueue.main.async { self.showSyncingAnimation = false }
|
|
|
return
|
|
|
}
|
|
|
+ let date = Date(timeIntervalSince1970: timestamp)
|
|
|
|
|
|
- let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
|
|
|
-
|
|
|
- guard snapshot.date > lastProcessed else {
|
|
|
- Task {
|
|
|
- await WatchLogger.shared.log("⌚️ Ignoring outdated or duplicate WatchState snapshot", force: true)
|
|
|
- }
|
|
|
+ // Wall-clock staleness gate. Drops the queued backlog cheaply when
|
|
|
+ // the watch app wakes after long disuse; without it, every payload
|
|
|
+ // schedules merge + UI work.
|
|
|
+ guard date >= Date().addingTimeInterval(-Self.maxAcceptableMessageAge) else {
|
|
|
+ Task { await WatchLogger.shared.log("⌚️ Skipping stale watch state (\(date))") }
|
|
|
+ DispatchQueue.main.async { self.showSyncingAnimation = false }
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- WatchStateSnapshot.saveLatestDateToDisk(snapshot.date)
|
|
|
+ // Monotonicity dedup.
|
|
|
+ let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
|
|
|
+ guard date > lastProcessed else {
|
|
|
+ Task { await WatchLogger.shared.log("⌚️ Skipping duplicate watch state (\(date))") }
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
+ WatchStateSnapshot.saveLatestDateToDisk(date)
|
|
|
DispatchQueue.main.async {
|
|
|
- self.scheduleUIUpdate(with: snapshot.payload)
|
|
|
+ self.scheduleUIUpdate(with: payload)
|
|
|
}
|
|
|
}
|
|
|
|